Hello world

在系统学习LLVM IR语法之前,我们应当首先掌握的是使用LLVM IR写的最简单的程序,也就是大家常说的Hello world版程序。这是因为,编程语言的学习,往往需要伴随着练习。但是一个独立的程序需要许多的前置语法基础,那么我们不可能在了解了所有前置语法基础之后才完成第一个独立程序。因此,正确的学习方式应该是,首先掌握这门语言独立程序的基础框架,然后每学习一个新的语法知识,就在框架中练习,并编译看结果是否是自己期望的结果。

综上所述,学习一门语言的第一步,就是掌握其最简单的程序的基本框架是如何写的。

最基本的程序

我们最基本的程序为:

; main.ll
define i32 @main() {
    ret i32 0
}

这个程序可以看作最简单的C语言代码:

int main() {
    return 0;
}

在Linux上编译而成的结果。

我们可以直接测试这个代码的正确性:

clang main.ll -o main
./main

使用clang可以直接将main.ll编译成可执行文件main。运行这个程序后,程序自动退出,并返回0。这正符合我们的预期。

基本概念

下面,我们对main.ll逐行解释一些比较基本的概念。

注释

首先,第一行; main.ll。这是一个注释。在LLVM IR中,注释以;开头,并一直延伸到行尾。所以在LLVM IR中,并没有像C语言中的/* comment block */这样的注释块,而全都类似于C语言中// comment line这样的注释行。

主程序

我们知道,主程序是可执行程序执行的入口点,所以任何可执行程序都需要main函数才能运行。所以,

define i32 @main() {
    ret i32 0
}

就是这段代码的主程序。关于正式的函数、指令的定义,我会在之后的文章中提及。这里我们只需要知道,在@main()之后的,就是这个函数的函数体,ret i32 0就代表C语言中的return 0;。因此,如果我们要增加代码,就只需要在大括号内,ret i32 0前增加代码即可。

目标平台和数据布局

目标平台

在我们使用clang编译这个LLVM IR代码时,实际上会报一个警告:

warning: overriding the module target triple with x86_64-unknown-linux-gnu [-Woverride-module]

这里所说的「target triple」是什么呢?

要搞清楚这个概念,我们就需要回忆一下之前提到的,LLVM解决的一大问题:可移植性。也就是,我们想让我们的编程语言,在尽可能多的平台上得到支持。所谓的平台,直观来看,就是CPU和操作系统。

众所周知,不同指令集的CPU,它们能够运行的二进制指令必然不同,对应的汇编代码也完全不一样。而对于不同的操作系统来说,其支持的可执行程序格式是不同的。Linux支持ELF格式的可执行程序,macOS支持Mach-O格式的可执行程序,Windows支持PE格式的可执行程序。不同格式之间,其所包含的元信息不同,二进制指令的组织形式也有可能不同。

即使CPU和操作系统一致,也有可能会有一些其他的原因导致生成的二进制程序不一致。因此,我们往往还会加上「vendor」这一项。所以笼统而言,CPU--vendor--OS这三者决定了一个平台,只要这三者一致,我们生成的二进制程序往往就可以确定了。这三者就被称为一个「目标三元组」(Target Triple)。

对于我们的实验环境,也就是AMD64架构下的Linux,往往会被称为x86_64-unknown-linux-gnu,这里x86_64指的是CPU架构,unknown是vendor,对于Linux环境,往往不太重要;linux指的是操作系统,而后面跟着的gnu则是指Linux是GNU-Linux,主打一个修饰作用。

类似地,在Apple Silicon Mac上的目标三元组就是aarch64-apple-darwin,在常见的PC机上的目标三元组就是x86_64-pc-windows-msvc

如果想消除编译时的警告,可以使用

clang main.ll -o main --mtriple "x86_64-unknown-linux-gnu"

或者在main.ll中加入一行

target triple = "x86_64-unknown-linux-gnu"

目标数据布局

除了目标平台外,我们还可以声明目标数据布局(Target Data Layout)。熟悉汇编语言以及二进制程序的开发者应该知道,我们在高级语言中常见的整型,如C中的intshort,Rust中的usizei8,在底层看来,大小端序、数据长度、数据对齐是不得不考虑的事。我们在声明目标平台时,往往就默认了其对应的数据布局。例如,对于AMD64架构的Linux来说,数据往往使用小端序,8位长度的数据按8位对齐。

LLVM也支持我们手动定制这样的数据布局,例如,我们可以在LLVM IR的源代码中写:

target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"

这一长串文字就定义了目标的数据布局。具体而言:

  • e: 小端序
  • m:e: 符号表中使用ELF格式的命名修饰
  • p270:32:32-p271:32:32-p272:64:64: 与地址空间有关
  • i64:64: 将i64类型的变量采用64位的ABI对齐
  • f80:128: 将long double类型的变量采用128位的ABI对齐
  • n8:16:32:64: 目标CPU的原生整型包含8位、16位、32位和64位
  • S128: 栈以128位自然对齐

具体每个参数的含义,可以参考Data Layout